
在程式執行時總會有意外,資料庫斷線、JSON 格式不對、找不到資源,如果框架不處理這些 exception,JDK HttpServer 可能只回一個粗糙的 500,甚至讓 request 卡住,這篇要做的是用 middleware 把 exception 統一攔下來,轉成可預期的 HTTP response
第 15 篇做了 Logging,這篇做 Error Handling,兩個搭配起來,你的框架就有了最基本的可觀測性和穩定性
框架需要一種方式讓 handler 用「丟 exception」表達 HTTP 錯誤。比起回傳 RelixResponse(400, ...),有時候 throw 更直覺,特別是在深層的 utility function 裡,你不想一路把 response 傳回來
open class RelixHttpException(
val statusCode: Int,
override val message: String,
) : RuntimeException(message)
用 open class 而不是 class,是因為之後可能會想定義子類別
class BadRequestException(message: String) : RelixHttpException(400, message)
class NotFoundException(message: String) : RelixHttpException(404, message)
class ForbiddenException(message: String) : RelixHttpException(403, message)
handler 裡就可以寫
get("/users/{id}") {
val id = pathParam("id").toIntOrNull()
?: throw BadRequestException("id must be a number")
val user = findUser(id)
?: throw NotFoundException("User $id not found")
ok(user.name)
}
比起 if-else 串一堆 return,這種寫法讓 happy path 比較突出,錯誤處理比較不會搶了風頭
測試要涵蓋三種情境,一般 exception、框架定義的 HTTP exception、開發模式 vs 生產模式
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFalse
class ErrorHandlingMiddlewareTest {
@Test
fun `general exception returns 500`() {
val app = RelixApplication()
app.use(errorHandlingMiddleware())
app.routing {
get("/boom") { throw RuntimeException("something broke") }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/boom")
assertEquals(500, response.statusCode)
assertTrue(response.bodyAsText().contains("Internal Server Error"))
}
@Test
fun `RelixHttpException returns specified status code`() {
val app = RelixApplication()
app.use(errorHandlingMiddleware())
app.routing {
get("/bad") { throw BadRequestException("invalid input") }
get("/missing") { throw NotFoundException("not here") }
}
val testKit = RelixTestKit(app)
val badResponse = testKit.handleRequest("GET", "/bad")
assertEquals(400, badResponse.statusCode)
assertTrue(badResponse.bodyAsText().contains("invalid input"))
val notFoundResponse = testKit.handleRequest("GET", "/missing")
assertEquals(404, notFoundResponse.statusCode)
assertTrue(notFoundResponse.bodyAsText().contains("not here"))
}
@Test
fun `development mode exposes exception class but not its message`() {
val app = RelixApplication()
app.use(errorHandlingMiddleware(development = true))
app.routing {
get("/boom") { throw RuntimeException("secret detail") }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/boom")
assertEquals(500, response.statusCode)
// 開發模式多回 exception 類別名稱,但 message 仍然不外流
assertTrue(response.bodyAsText().contains("RuntimeException"))
assertFalse(response.bodyAsText().contains("secret detail"))
}
@Test
fun `production mode hides exception details`() {
val app = RelixApplication()
app.use(errorHandlingMiddleware(development = false))
app.routing {
get("/boom") { throw RuntimeException("secret detail") }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/boom")
assertEquals(500, response.statusCode)
assertFalse(response.bodyAsText().contains("secret detail"))
assertTrue(response.bodyAsText().contains("Internal Server Error"))
}
@Test
fun `normal request passes through untouched`() {
val app = RelixApplication()
app.use(errorHandlingMiddleware())
app.routing {
get("/hello") { ok("Hello!") }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/hello")
assertEquals(200, response.statusCode)
assertEquals("Hello!", response.bodyAsText())
}
}
先用 try/catch 寫最直覺的版本
fun errorHandlingMiddleware(development: Boolean = false): RelixMiddleware = { next ->
try {
next()
} catch (e: java.util.concurrent.CancellationException) {
throw e
} catch (e: RelixHttpException) {
errorResponse(e.statusCode, e.message, development, e)
} catch (e: Exception) {
errorResponse(500, "Internal Server Error", development, e)
}
}
errorHandlingMiddleware 是工廠函式,跟第 15 篇的 loggingMiddleware 一樣的模式。development 只控制 response 裡要不要多回一個 exception class 名稱,原始 message 與 stack trace 不管哪個模式都不會出現在 response 裡
兩層 catch 的順序有意義,RelixHttpException 在前面,因為它是 RuntimeException 的子類別。如果把 Exception 放前面,RelixHttpException 永遠不會被單獨處理
錯誤回應先用純文字,body 就是那句 message
private fun errorResponse(
statusCode: Int,
message: String,
development: Boolean,
cause: Exception,
): RelixResponse {
val body = if (development) {
"$message\n${cause::class.simpleName ?: "Exception"}"
} else {
message
}
return RelixResponse(
statusCode = statusCode,
headers = mapOf("Content-Type" to listOf("text/plain; charset=utf-8")),
body = body.toByteArray(Charsets.UTF_8),
)
}
開發模式只多回一行 exception class name,生產模式就只有那句固定 message
實際輸出長這樣
生產模式
Internal Server Error
400 這條路線回的是 exception 自己帶的 message
invalid input
開發模式
Internal Server Error
RuntimeException
完整 stack trace 可能包含檔案路徑、內部類別與其他實作細節,不應放進 HTTP response,開發時要看完整資訊,這些該寫進 server log
不過要先講清楚,這版 middleware 手上沒有 logger,所以 stack trace 是真的被丟掉了,不是留在別的地方,這是為了先把攔截與分派這件事講乾淨才做的簡化,補法後面有獨立一節講
Kotlin 標準函式庫有 runCatching,也能寫成函式風格
fun errorHandlingMiddleware(development: Boolean = false): RelixMiddleware = { next ->
runCatching { next() }.getOrElse { cause ->
when (cause) {
is java.util.concurrent.CancellationException -> throw cause
is RelixHttpException ->
errorResponse(cause.statusCode, cause.message, development, cause)
is Exception ->
errorResponse(500, "Internal Server Error", development, cause)
else -> throw cause // Error 不攔,往外丟
}
}
}
runCatching 執行 lambda,成功就回 Result.success(response),失敗就回 Result.failure(exception)。getOrElse 在成功時直接拿值,失敗時用 lambda 處理
兩種寫法結果一樣,try/catch 比較明確,runCatching 比較簡潔,就看偏好用那一個都行
順便釐清一下 Result<T> 的定位,Kotlin 的 Result 在 stdlib 是 inline value class,主要用來包住「成功或失敗」這個結果,它跟 exception 不是對立關係,runCatching 內部還是用 try/catch 攔住例外,只是把它包成 Result.failure,真正的差別在「失敗的傳遞方式」,exception 順著 stack 自動往上拋,沒接到就往外冒,Result 則必須由呼叫端決定怎麼處理 (getOrThrow()、getOrElse {}、fold {} {}),錯誤路徑變成顯式的資料流,框架程式碼用 runCatching 通常是想要在這一層「決定什麼錯該攔、什麼錯該重拋」,這正是 ErrorHandling middleware 在做的事,Result 不一定適合當業務 API 的回傳型別,但在「攔截 + 分派」的情境很順手
有一個細節要注意,runCatching 會攔住所有 Throwable,包括 Error (OutOfMemoryError 之類的),上面的 when 裡有 else -> throw cause,就是為了把 Error 重新丟出去,如果你用 try/catch (e: Exception),Error 本來就不會被攔
第 15 篇提到 logging middleware 該不該包 try/catch 的問題,有了 error handling middleware,答案就很明確了
val app = RelixApplication()
app.use(loggingMiddleware(logger)) // 1. 最外層
app.use(errorHandlingMiddleware()) // 2. 第二層
Request 進來的執行順序
→ logging (before: 記 method/path,開始計時)
→ error handling
→ handler(可能 throw exception)
← error handling(把 exception 轉成 response)
← logging (after: 記 status code 和耗時)
Logging 拿到的永遠是正常的 response (不管 handler 有沒有丟 exception),因為 error handling 已經在裡面把 exception 轉成 response 了,兩個 middleware 各做各的事,互不干擾
如果把順序反過來 (error handling 在外,logging 在內),logging 就可能因為 exception 而跳過 after 邏輯
這個搭配值得補一個測試確認,不然「logging 拿到的永遠是正常 response」就只是嘴上說說
@Test
fun `logging records converted status when handler throws`() {
val logger = FakeLogger()
val app = RelixApplication()
app.use(loggingMiddleware(logger))
app.use(errorHandlingMiddleware())
app.routing {
get("/boom") { throw RuntimeException("something broke") }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/boom")
assertEquals(500, response.statusCode)
// logging 的 after 沒有被 exception 跳過
assertEquals(1, logger.messages.size)
val log = logger.messages.first()
assertTrue(log.contains("GET"))
assertTrue(log.contains("/boom"))
assertTrue(log.contains("500"))
// exception 在裡面就被轉成 response 了,沒有傳到 logging 這一層
assertTrue(logger.errors.isEmpty())
}
FakeLogger 是第 15 篇那個,兩個 middleware 裝進同一個 app,這個測試才測得到搭配起來的行為,最後那行 logger.errors.isEmpty() 是重點,它證明 logging 走的是 info 這條路,exception 根本沒有傳到最外層,安裝順序確實有發揮作用
看一個完整的使用情境
fun main() {
val app = RelixApplication()
app.use(loggingMiddleware())
app.use(errorHandlingMiddleware(development = true))
app.routing {
get("/users/{id}") {
val id = pathParam("id").toIntOrNull()
?: throw BadRequestException("id must be a number")
ok("User: $id")
}
}
JdkHttpServerAdapter(app).start(8080)
}
兩個 middleware 的順序就是前面那節排的,logging 在外、error handling 在內
curl -i localhost:8080/users/abc
curl localhost:8080/users/42
/users/abc 轉不成數字,handler 直接 throw,curl -i 看到的是 (省略 Date 這個 header)
HTTP/1.1 400 Bad Request
Content-type: text/plain; charset=utf-8
Content-length: 39
id must be a number
BadRequestException
header 名稱印出來是 Content-type 而不是程式裡寫的 Content-Type,這是 JDK HttpServer 自己做的正規化,HTTP header 名稱本來就不分大小寫,不影響 client 解析
Content-length: 39 也可以拿來反推 body,id must be a number (19) 加換行 (1) 加 BadRequestException (19) 剛好 39,代表回去的就只有這兩行,stack trace 沒有跟著外流
body 第二行的 BadRequestException 是因為這裡開了 development = true,換成 false 就只剩第一行那句 message
/users/42 則正常回 200 跟 User: 42,server 那邊的 terminal 兩筆都記得到
[Relix] GET /users/abc -> 400 (5ms)
[Relix] GET /users/42 -> 200 (0ms)
可以記錄下 400 那筆,正是因為 error handling 裝在 logging 裡面,exception 在內層就被轉成 response,logging 的 after 才有機會跑到
handler 的邏輯很乾淨,驗證失敗就 throw,成功就回 response,不用自己處理錯誤格式
目前的 error handling middleware 把 exception 轉成 response 就結束了,沒有人把它記下來,開發模式的 response 裡只剩一個 class 名稱,生產模式連這個都沒有,也就是說 stack trace 到這裡就找不回來了,出事的時候只知道「500 了」,不知道 500 在哪一行
這裡不能指望外層的 logging middleware 幫忙,前面那個測試最後一行 logger.errors.isEmpty() 就是證據,exception 在內層已經被轉成 response,傳到 logging 那一層的是一個長得很正常的 500,logging 記得到 status code,記不到 exception
要留下 stack trace,得讓 error handling middleware 自己拿到 logger,寫法跟第 15 篇的 loggingMiddleware 一樣,參數給一個預設值
fun errorHandlingMiddleware(
development: Boolean = false,
logger: RelixLogger = ConsoleLogger(),
): RelixMiddleware = { next ->
try {
next()
} catch (e: java.util.concurrent.CancellationException) {
throw e
} catch (e: RelixHttpException) {
errorResponse(e.statusCode, e.message, development, e)
} catch (e: Exception) {
logger.error("Unhandled exception", e)
errorResponse(500, "Internal Server Error", development, e)
}
}
有了預設值,前面那些 errorHandlingMiddleware(development = true) 的呼叫一行都不用改,測試想驗證有沒有記到,換 FakeLogger 傳進去就好
只有 500 那條路線要記,RelixHttpException 是 handler 主動丟的,屬於預期內的錯誤,/users/abc 那種打錯網址的 request 每天都有一堆,全記進 error log 只是製造雜訊
如果想讓 logging 跟 error handling 用同一個 logger,在 main 裡 new 一次傳給兩邊
val logger = ConsoleLogger()
val app = RelixApplication()
app.use(loggingMiddleware(logger))
app.use(errorHandlingMiddleware(development = true, logger = logger))
各自用預設值也能跑,ConsoleLogger 本身沒有狀態,但第 15 篇後面那個 ConsoleLogger(minLevel) 補上來之後,兩個 instance 就可能設成不同的 level,變成 logging 可以記得到 error handling 被濾掉這種不好查的狀況,共用一個也比較省事
裝上之後,走到 500 那條路線的 request 在 terminal 會看到兩筆,/users/abc 那種 400 不會多出這一筆,它走的是 RelixHttpException 那個 catch,想實際看到 stack trace,加一條路線來打
get("/boom") { throw RuntimeException("something broke") }
curl localhost:8080/boom 之後會出現
[Relix ERROR] Unhandled exception
java.lang.RuntimeException: something broke
at MainKt$main$1$2.invokeSuspend(main.kt:25)
at ErrorHandlingMiddlewareKt$errorHandlingMiddleware$1.invokeSuspend(ErrorHandlingMiddleware.kt:11)
at PipelineKt$buildPipeline$1.invokeSuspend(Pipeline.kt:24)
at LoggingMiddlewareKt$loggingMiddleware$1.invokeSuspend(LoggingMiddleware.kt:14)
at PipelineKt$buildPipeline$1.invokeSuspend(Pipeline.kt:24)
at RelixApplication.handle(RelixApplication.kt:39)
at JdkHttpServerAdapter$start$1$response$1.invokeSuspend(JdkHttpServerAdapter.kt:27)
...
[Relix] GET /boom -> 500 (6ms)
[Relix ERROR] 這個前綴跟後面的 stack trace 來自第 15 篇的 ConsoleLogger,它的 error() 就是印一行 message 再 cause?.printStackTrace(System.err)
兩筆的先後順序有意思,exception 那筆在前,GET /boom -> 500 那筆在後,因為 error handling 在內層,它記完 exception、把 exception 轉成 500 交回外層,logging 才有東西可以記
這段 stack trace 還順便把 middleware 的巢狀結構整個攤開來,由下往上讀是 JdkHttpServerAdapter → RelixApplication.handle → loggingMiddleware → buildPipeline → errorHandlingMiddleware → handler (main.kt:25),跟前面那張執行順序的圖對得上,logging 確實在外面,error handling 確實在裡面,這比看圖更有說服力,它是機器印出來的
為什麼錯誤回應先用純文字 ?
API 框架的 client 通常是程式,不是人,結構化格式當然比純文字好處理,這點沒有疑問,但 JSON 序列化要到第 20 篇的 Content Negotiation 才會接上,這裡為了吐一句錯誤訊息就先手刻一套字串跳脫 (引號、反斜線、換行都得自己處理),跟這篇要講的攔截與分派沒什麼關係,先用純文字把機制講清楚,等 Content Negotiation 做完之後,錯誤回應就能跟一般回應走同一套 converter,第 27 篇的 StatusPages 再把 errorResponse() 換成結構化的 { "status": ..., "message": ... }
順帶對照一下第 15 篇,那篇說結構化 log (JSON) 整個系列都不做,這篇卻要為錯誤回應鋪路,差別在於 log 是對內的輸出通道,格式想換就換一個 RelixLogger 實作,沒有人依賴它,error response 是對外的 HTTP contract,client 會照著它寫程式,所以值得後面專門處理
errorResponse 為什麼是 private function 而不是 method ?
因為 error handling middleware 是一個獨立的函式,不屬於任何 class,把 helper 放在同一個檔案裡用 private 就好,如果你之後把 error handling 做成 Plugin (第 18 篇),再考慮要不要放進 class 裡
ErrorHandlingMiddleware 把 handler 裡的 exception 統一轉成 HTTP response,RelixHttpException 讓 handler 可以用 throw 表達 HTTP 錯誤,開發模式可回 exception class,生產模式只回固定訊息,完整 stack trace 一律不進 response,要留就得讓 middleware 自己拿 logger 記下來,跟 logging middleware 搭配時,把 error handling 裝在裡面、logging 裝在外面,到第 27 篇會做更完整的 StatusPages 系統,讓錯誤回應可以客製化
下一篇做 CORS middleware,處理瀏覽器跨網域 request 與 preflight (OPTIONS),讓 API 能被前端正常呼叫
同步刊登於 Blog
圖片來源:AI 產生